Javascript 是面向对象语言,但不同于 C++、Java 等基于类的实现原理,JS 的面向对象和继承都是基于原型链实现的。本文就将深入浅出的对其实现原理进行分析。
# 一、 语言特性
开始剖析原理前,需要记住几个 JS 语言层面的特性:
- 特性1:函数都是对象,其实引用类型(function、object、array)都是对象; —— 可由 typeof 和 instanceof 验证
- 特性2:函数都有一个 prototype 属性; —— 属性值是一个对象,称为“原型对象”
- 特性3:对象都是函数创建的,对象都是属性的集合; —— {} 等形式只是语法糖;
- 特性4:对象都有一个 __proto__ 属性; —— 即“隐式原型”,属性值指向创建该对象的函数的“原型对象”;
# 二、 函数与对象的关系
上边提到“函数都是对象”、“对象都是函数创建的”,那么二者到底什么关系呢?
一种常见的创建对象的方式如下:
var foo = new Fn()
通常,我们称Fn
为构造函数,foo
为实例对象。
通过上述特性可知:
构造函数也是函数,所以它也有一个prototype
属性,属性值是一个对象,称为该函数的原型对象。
实例对象也是对象,所以它也有一个__proto__
属性,属性值也是原型对象。
此外,原型对象默认有一个constrcutor
属性,指向构造函数。
即以下关系是成立的:
- foo.__proto__ === Fn.prototype
- Fn.prototype.constructor = Fn
实例对象、构造函数、原型对象 三者间的关系如下图:
上图中原型对象(Fn.prototype)也是对象,那么它的 __proto__ 属性值是什么呢?
其实,自定义函数的 prototype 本质上和 var obj = {} 是一样的,都是被 Object 函数创建,所以它的__proto__指向的就是Object.prototype。但Object.prototype比较特殊,它的__proto__指向null。
原型链关系可总结为下图:
很容易得知:console.log(f1 instanceof Foo) // true。instanceof第一个参数是对象,暂时称为A;第二个参数是函数,暂时称为B。
instanceof 的判断规则是:如果 B 的 prototype 出现在 A 的 __proto__ 原型链上,则返回true,否则返回false。
// 实现
function myInstanceof(left, right) {
let proto = Object.getPrototypeOf(left)
let prototype = right.prototype
while (true) {
if (proto === null) return false // 函数中的 return 会提前终止函数,即便 return 在循环中
if (proto === prototype) return true
proto = Object.getPrototypeOf(proto)
}
}
通过上述规则,可以解析以下怪异现象:
console.log(Object instanceof Function) // true
console.log(Function instanceof Object) // true
console.log(Function instanceof Function) // true
# 三、 原型链与继承
访问一个对象的属性时,先在对象自己的属性中查找,如果没有,再沿着 __proto__ 这条链向上查找,直到 Object.prototype,这就是原型链。所以,js 继承是基于原型链实现的。
如何区分一个属性是自身的还是从原型中找到的呢?可以使用 hasOwnProperty。
如何获取原型? o.__proto__ 或 o.constructor.prototype 或 Object.getPrototypeOf(o)
# 四、 V8 是如何创建对象的
Js 代码在执行时,会被 V8 引擎解析,这时 V8 会用不同的模板来处理 Js 中的对象和函数。
例如:
- ObjectTemplate 用来创建对象
- FunctionTemplate 用来创建函数
- PrototypeTemplate 用来创建函数原型
我们可以得到以下结论:
- Js 中的函数都是 FunctionTemplate 创建出来的,返回值的是 FunctionTemplate 实例。
- Js 中的对象都是 ObjectTemplate 创建出来的,返回值的是 ObjectTemplate 实例。
- Js 中函数的原型(prototype)都是通过 PrototypeTemplate 创建出来的,返回值是 ObjectTemplate 实例。
所以 Js 中的对象的原型可以这样判断:
- 所有的对象的原型都是 Object.prototype,自定义构造函数的实例除外。
- 自定义构造函数的实例,它的原型是对应的构造函数原型。
在 Js 中的函数原型判断就更加简单了:
- 所有的函数原型,都是 Function.prototype。
- 所有的内置构造函数,他们的原型都是 Function.prototype。
# 五、 几种常见的继承实现
原型链继承
本质:将两个原本无关联的构造函数,通过原型链建立起继承关系
// 父类构造函数 function Father() { this.name = 'father' } // 子类构造函数 function Son() {} // 要构成继承关系,通过上面的关系图可知,需满足原型链关系 Son.prototype.__proto__ === Father.prototype === (new Father()).__proto__ 也就是如下关系 Son.prototype = new Father() var instance = new Son() console.log(instance.name) // father
缺点:父类中的引用类型属性在子类实例中共用,会导致数据篡改
function Father() { this.like = ['apple'] } function Son() {} Son.prototype = new Father() var instance_1 = new Son() instace_1.like.push('orange') var instance_2 = new Son() instace_2.like.push('banana') console.log(instance_1.like) // ['apple', 'orange', 'banana']
构造函数继承
本质:创建子类实例时,都调用一下父类的构造函数,等价于子类实例中完整复制一份父类实例的属性。(因为执行了父类的构造函数,所以是复制父类实例,而不是父类原型)
function Father() { this.like = ['apple'] } function Son() { // 每次子类实例化,调用父类构造函数,生成父类实例的副本,复制父类实例属性 Father.call(this) } var instance_1 = new Son() instace_1.like.push('orange') var instance_2 = new Son() instace_2.like.push('banana') console.log(instance_1.like) // ['apple', 'orange']
缺点:
- 只能继承父类实例的属性和方法,不能继承原型的属性和方法。
- 每次子类实例化,都要在内存中生成一份父类实例的副本,消耗性能。
组合继承
本质:组合上述两种方式,用原型链继承实现对原型属性和方法的继承,用构造函数继承实现对父类实例属性和方法的继承。
function Father() { this.like = ['apple'] } function Son() { // 构造函数继承 Father.call(this) // 第二次调用父类构造函数 } // 原型链继承 Son.prototype = new Father() // 第一次调用父类构造函数 // 重写子类原型的constructor属性,指向自己的构造函数 Son.prototype.constructor = Son
缺点:
- 在使用子类创建实例对象时,其原型中会存在两份相同的属性/方法
ES6类继承extends
ES6引入了class来定义类,并引入了extends来实现继承。本质:先调用父类的构造函数,创建父类的实例对象this,然后再用子类的构造函数来修改this。用法如下:
class B extends A { constructor(x, y, color) { super(x, y); // 调用父类的constructor(x, y) this.color = color } }
存在两条继承链:
- B.__proto__ = A 作为一个对象,子类的隐式原型是父类。
- B.prototype.__proto__ = A.__proto__ 作为一个构造函数,子类的显示原型的隐式原型是父类的显示原型。
extends实现继承的核心代码(寄生组合式继承):
function _inherits(subType, superType) { // 创建对象,创建父类原型的一个副本 // 增强对象,弥补因重写原型而失去的默认的constructor 属性 // 指定对象,将新创建的对象赋值给子类的原型 subType.prototype = Object.create(superType && superType.prototype, { constructor: { value: subType, enumerable: false, writable: true, configurable: true } }); if (superType) { Object.setPrototypeOf ? Object.setPrototypeOf(subType, superType) : subType.__proto__ = superType; } }
这是最成熟的实现继承的方法。有点:1.只调用了一次SuperType 构造函数,并且因此避免了在SubType.prototype 上创建不必要的、多余的属性。2.原型链还能保持不变,因此,还能够正常使用instanceof 和isPrototypeOf()
# 六、new 做了什么
var a = new A(a, b);
当这段代码运行的时候,内部实际上执行的是:
// 1. 创建一个空对象
var o = {};
// 2. 将空对象的隐式原型指向为构造器函数的显式原型
o.__proto__ = A.prototype; // Object.setPrototypeOf(o, Fn.prototype)
// 3. 将构造函数的this指向空对象,并执行该构造函数将属性和方法添加到空对象上,生成实例对象
A.call(o, a, b);
注意:构造函数尽量不要返回值。1.无返回值时,会返回创建的实例对象;2.返回原始值时,会忽略该值,并依然返回创建的实例对象;3.返回对象时,new操作符失效,会像执行了函数一样返回该对象。
自己实现 new 操作符:
// Fn - 构造函数,args - 构造函数的传参
function myNew(Fn, ...args) {
let obj = {}
Object.setPrototypeOf(obj, Fn.prototype)
let rtn = Fn.apply(obj, args)
return rtn instanceof Object ? rtn : obj // 原因见上述“注意”
}